iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0

我們來接續昨天製作的管理分類功能吧,昨天我們實作查詢和刪除的功能,今天我們將進一步實作新增分類的功能,讓使用者能夠輕鬆地將新的分類加入到管理系統中。Let's GO!

目標

今天的目標是實作一個新增分類的功能,這包括讓使用者輸入分類名稱、選擇分類圖案,以及選擇對應的大分類。完成後,新的分類將被儲存至資料庫中,並且即時更新至分類列表。UI 設計和昨天一樣都是參考簡單記帳

https://ooorito.com/wp-content/uploads/2024/09/%E7%9B%AE%E6%A8%99-1.webp

主要實作

實作 AddCategoryViewModel

新增分類的 ViewModel - AddCategoryViewModel。
這個 ViewModel 將負責負責新增分類資料,我們需要在 ViewModel 中實作以下功能:

  • 顯示大分類資料的陣列:categoryGroups,用來儲存從資料庫中抓取的所有大分類。
  • 顯示成功訊息:showSuccessToast,當新增分類成功時,觸發這個變數來顯示通知。
  • 顯示失敗訊息:failHandle,用來處理錯誤訊息,當新增失敗時,顯示錯誤提示。
  • 負責控制 Core Data 資料的物件:DataManager,這是我們與 Core Data 互動的主要接口。
  • 抓取大分類的函數fetchCategoryGroups(),用來從資料庫中載入所有已存在的大分類。
  • 新增分類的函數addCategory(name:iconName:categoryGroup:),負責將使用者輸入的分類名稱、圖案和選擇的大分類存入 Core Data。

以下是 AddCategoryViewModel 的完整程式碼:

import SwiftUI

class AddCategoryViewModel: ObservableObject {
    @Published var categoryGroups: [CategoryGroup] = []
    @Published var showSuccessToast: Bool = false
    @Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
    
    private let dataManager: DataManager
    
    init(dataManager: DataManager = DataManager()) {
        self.dataManager = dataManager
        fetchCategoryGroups()
    }
    
    func fetchCategoryGroups() {
        categoryGroups = dataManager.fetchCategoryGroups()
    }
    
    func addCategory(name: String, iconName: String, categoryGroup: CategoryGroup) {
        if name == "" {
            failHandle = (isFail: true, title: "發生錯誤")
        } else {
            let result = dataManager.addItemCategory(name: name, iconName: iconName, categoryGroup: categoryGroup)
            if result {
                showSuccessToast = true
            } else {
                failHandle = (isFail: true, title: "發生錯誤")
            }
        }
    }
}

實作 AddCategoryView

接下來,我們實作 AddCategoryView,用來讓使用者新增分類的頁面。我們會依照前面設計的 ViewModel,來實現 UI 的互動和資料處理。這個頁面允許使用者輸入分類名稱、選擇圖案以及選擇對應的大分類。接下來,我們將一步一步實作這個 View。

初始化與狀態管理

在 AddCategoryView 中,我們定義了一些 State 變數來管理頁面上的互動資料:

  • categoryName:使用者輸入的新分類名稱。
  • selectedCategoryGroup:使用者選擇的大分類,預設為第一個分類,如果沒有分類則顯示空的 CategoryGroup。
  • selectedIconName:使用者選擇的圖案,預設為 "frying.pan"。
  • icons:提供給使用者選擇的子分類 icon 陣列,裡面有 100 個 SF Symbol 名稱。

透過 @ObservedObject 來監聽 AddCategoryViewModel,這樣可以讓 UI 動態更新,並透過 @Environment(\.dismiss) 來管理頁面的返回功能。

struct AddCategoryView: View {
    @State private var categoryName: String = ""
    @State private var selectedCategoryGroup: CategoryGroup
    @State private var selectedIconName: String
    @ObservedObject var viewModel: AddCategoryViewModel
    @Environment(\.dismiss) private var dismiss
    let icons = [
        "frying.pan", "fork.knife", "cup.and.saucer", "oven", // 其餘省略...
    ]
    //以下略...

在挑選 SF Symbols 圖案時,請務必確認這些圖案支援的 iOS 版本,因為有些圖案是 iOS 18 才新增的,這代表在 iOS 18 以前的系統中無法正常顯示這些圖案。

https://ooorito.com/wp-content/uploads/2024/09/SF-Symbols-1024x762.webp

參考資料:SF Symbols

輸入框與圖案顯示

頁面的上半部分是一個分類名稱的輸入框,左邊顯示使用者選定的圖案。如果使用者還沒有選擇圖案,預設顯示的是 "frying.pan" 圖案。輸入框的設計上使用了 TextField,並且包在一個有灰色背景的框架中。

HStack {
    if !selectedIconName.isEmpty {
        Image(systemName: selectedIconName)
            .resizable()
            .scaledToFit()
            .frame(width: 40, height: 40)
            .foregroundColor(Color(hexString: selectedCategoryGroup.colorHex))
    }

    TextField("請輸入分類名稱", text: $categoryName)
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(10)
}

大分類選擇

中間的區域是大分類的選擇區塊。這裡使用 LazyVGrid 來顯示大分類,當使用者點擊某個分類時,該分類的背景色會變成分類的代表色,並且文字顏色會變成白色。這部分的選擇使用 Button 來處理點擊事件,每次點擊會更新 selectedCategoryGroup

let width = UIScreen.main.bounds.width / 6
LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 16) {
    ForEach(viewModel.categoryGroups, id: \.id) { group in
        let isSelected = selectedCategoryGroup == group
        let backgroundColor = isSelected ? Color(hexString: group.colorHex) : Color(.clear)
        let textColor = isSelected ? Color.white : Color.black

        Button(action: {
            selectedCategoryGroup = group
        }) {
            Text(group.name)
                .font(.system(size: 16, weight: .bold))
                .padding()
                .frame(maxWidth: .infinity)
                .background(backgroundColor)
                .foregroundColor(textColor)
                .cornerRadius(10)
                .overlay(
                    RoundedRectangle(cornerRadius: 10)
                        .stroke(Color.gray, lineWidth: 2)
                )
        }
    }
}

圖案選擇

頁面的下方區域是圖案選擇區,使用 LazyVGrid 來顯示多個圖案,並允許使用者點擊來選擇圖案。選中的圖案會以大分類的顏色來顯示,而未選中的圖案則保持灰色。

ScrollView {
    LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
        ForEach(icons, id: \.self) { icon in
            Button(action: {
                selectedIconName = icon
            }) {
                Image(systemName: icon)
                    .resizable()
                    .foregroundColor(selectedIconName == icon ? Color(hexString: selectedCategoryGroup.colorHex) : Color.gray)
                    .scaledToFit()
                    .frame(width: 50, height: 50)
                    .padding()
                    .cornerRadius(10)
            }
        }
    }
    .padding(.horizontal)
}

確認按鈕與新增分類

頁面底部設有一個確認按鈕,當使用者輸入名稱並選擇好圖案和分類後,點擊按鈕即可將分類資訊保存至資料庫。這個過程會呼叫 ViewModel 中的 addCategory() 方法,並在操作完成後顯示成功或失敗的通知。

Button(action: {
    viewModel.addCategory(name: categoryName, iconName: selectedIconName, categoryGroup: selectedCategoryGroup)
}) {
    Text("確認")
        .frame(maxWidth: .infinity)
        .padding()
        .background(Color.blue)
        .foregroundColor(.white)
        .cornerRadius(10)
}

成功與失敗通知

為了提高 UX (使用者體驗),我們新增兩個 AlertToast 通知元件,一個用於顯示成功訊息,另一個用於顯示錯誤訊息。當 ViewModel 中的 showSuccessToasttrue 或 failHandle.isFailtrue 時,會分別觸發成功或失敗的提示。

.toast(isPresenting: $viewModel.showSuccessToast, alert: {
    AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
    dismiss()
})

.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
    AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})

以下提供 AddCategoryView 完整程式碼:

import SwiftUI
import AlertToast

struct AddCategoryView: View {
    @State private var categoryName: String = ""
    @State private var selectedCategoryGroup: CategoryGroup
    @State private var selectedIconName: String
    @ObservedObject var viewModel: AddCategoryViewModel
    @Environment(\.dismiss) private var dismiss
    
    init(viewModel: AddCategoryViewModel = AddCategoryViewModel()) {
        _viewModel = ObservedObject(wrappedValue: viewModel)
        // 確保 categoryGroups 中有資料,否則預設為一個空的 CategoryGroup
        let initialGroup = viewModel.categoryGroups.first ?? CategoryGroup()
        _selectedCategoryGroup = State(initialValue: initialGroup)
        _selectedIconName = State(initialValue: "frying.pan")
    }
    
    let icons = [
        "frying.pan", "fork.knife", "cup.and.saucer", "oven", "refrigerator.fill", "trash.fill", "scissors", "hands.and.sparkles", "microwave", "fanblades.fill", "air.purifier", "lightbulb.fill", "bed.double.fill", "sofa.fill", "cabinet.fill", "chair.fill", "book.fill", "folder.fill", "printer.fill", "desktopcomputer", "keyboard", "tshirt.fill", "shoe.fill", "eyeglasses", "bag.fill",  "pencil", "eraser.fill", "beach.umbrella.fill", "comb.fill", "toilet.fill", "shower.fill", "gamecontroller.fill", "bandage.fill", "face.smiling.inverse", "bicycle", "basketball.fill", "soccerball", "tennis.racket", "dumbbell.fill", "leaf.fill", "tree.fill", "camera.macro", "carrot.fill", "birthday.cake.fill", "cup.and.saucer.fill", "wineglass.fill","mug.fill", "takeoutbag.and.cup.and.straw.fill", "waterbottle.fill", "dog.fill", "cat.fill", "hare.fill", "tortoise.fill", "lizard.fill", "bird.fill", "fish.fill", "pawprint.fill", "hammer.fill", "screwdriver.fill", "wrench.fill", "oar.2.crossed", "car.fill", "scooter", "iphone", "laptopcomputer", "ipad", "headphones", "applewatch", "tv", "speaker.fill", "keyboard.fill", "minus.plus.batteryblock.stack.fill", "camera.fill", "photo.artframe", "book.closed.fill", "tray.full.fill", "tag.fill",  "flashlight.on.fill", "moon.fill", "sun.min.fill"
    ]
    
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            // 輸入分類名稱
            HStack {
                if !selectedIconName.isEmpty {
                    Image(systemName: selectedIconName)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 40, height: 40)
                        .foregroundColor(Color(hexString: selectedCategoryGroup.colorHex))
                }
                
                TextField("請輸入分類名稱", text: $categoryName)
                    .padding()
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(10)
            }
            .padding(.horizontal)
            
            let width = UIScreen.main.bounds.width / 6
            LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 16) {
                ForEach(viewModel.categoryGroups, id: \.id) { group in
                    let isSelected = selectedCategoryGroup == group
                    let backgroundColor = isSelected ? Color(hexString: group.colorHex) : Color(.clear)
                    let textColor = isSelected ? Color.white : Color.black
                    
                    Button(action: {
                        selectedCategoryGroup = group
                    }) {
                        Text(group.name)
                            .font(.system(size: 16, weight: .bold))
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(backgroundColor)
                            .foregroundColor(textColor)
                            .cornerRadius(10)
                            .overlay(
                                RoundedRectangle(cornerRadius: 10)
                                    .stroke(Color.gray, lineWidth: 2)
                            )
                    }
                }
            }
            .padding(.horizontal)
            .padding(.vertical)
            
            
            ScrollView {
                LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
                    ForEach(icons, id: \.self) { icon in
                        Button(action: {
                            selectedIconName = icon
                        }) {
                            Image(systemName: icon)
                                .resizable()
                                .foregroundColor(selectedIconName == icon ? Color(hexString: selectedCategoryGroup.colorHex) : Color.gray)
                                .scaledToFit()
                                .frame(width: 50, height: 50)
                                .padding()
                                .cornerRadius(10)
                        }
                    }
                }
                .padding(.horizontal)
            }
            
            Spacer()
            
            // 確認新增分類的按鈕
            Button(action: {
                viewModel.addCategory(name: categoryName, iconName: selectedIconName, categoryGroup: selectedCategoryGroup)
            }) {
                Text("確認")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .padding(.horizontal)
        }
        .navigationBarTitle("新增分類", displayMode: .inline)
        .toast(isPresenting: $viewModel.showSuccessToast, alert: {
            AlertToast(type: .complete(Color.green), title: "完成")
        }, completion: {
            dismiss()
        })
        
        .toast(isPresenting: $viewModel.failHandle.isFail, alert: {
            AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
        })
    }
}



#Preview {
    AddCategoryView()
}

Day17 成果

總結

今天我們成功實作 AddCategoryView 和對應的 AddCategoryViewModel,讓使用者可以新增新的分類至 App 中。我們使用了動態的 UI 元件來讓使用者輸入分類名稱、選擇圖示及大分類,並將新增的分類即時保存至資料庫中。明天我們會將管理地點的功能實作出來。


上一篇
Day 16: SwiftUI 分類列表設計與實作
下一篇
Day18: SwiftUI 地點管理功能實作
系列文
用 SwiftUI 掌控家庭日用品庫存30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言